APK瘦身

APK 瘦身预研

APK 瘦身的原因

1、下载转化率
现在很多大型的 App 一般都会有一个 Lite 版本的 App,这个也是出于下载转化率方面的考虑。
2、应用市场
Google Play 应用市场强制要求超过 100MB 的应用只能使用 APK 扩展文件方式 上传。
aab 格式(Android App Bundles
3、渠道合作要求
越大的 App 其单价成本也会越高。百度浏览器包体积严格控制在 5M 内,5M 是红线,超过 5M 发版要总监审批
4、包过大对 App 性能影响

  1. 安装时间过长
  2. 运行时内存占用过大
  3. ROM 空间占用过大

APK 瘦身目标

  1. DuBrowser,明确了包体积要在 5M 以内,超过 5M 红线发版本需总监审批
  2. Shein,AAB 包不要超过 300M

APK 分析工具

Android Studio 自带的 Analyze APK 来做的包体积分析,主要就是做了代码、资源、So 等三个方面的重点优化。

APK 优化思路

APK 的组成

将 APK 拖到 Android Studio 中查看 APK 中的内容了
zq3ba

  1. lib

so 和 aar 文件

  1. res 资源文件

drawable、layout、color、raw 等资源文件夹

  1. assets

一些资源,礼物动画 mp4 资源、svga 资源等

  1. classesX.dex 类

Java 代码编译成的 class,通过 dex 工具处理成的 dex 文件

  1. 其他

根据 apk 的组成可知,占用 apk 比较大的主要是 资源(res)代码(classesX.dex)so(lib) 这三方面优化

APK 分析工作

apk 压缩包按照资源文件类型分类,主要有:so 资源(程序运行依赖的库,如接入 UC 浏览器内核 SDK 时,引入的 so 达到惊人的 12M)、图片资源(png、webp、jpg 等)、Java 代码(dex 文件)、xml 代码这几类,此外还可横向统计 flutter 相关资源情况。
由于可以拿到单个文件的信息,所以我们开发了工具解析 apk 包中的内容,从文件类型角度分析包资源占比情况,以及将资源文件按照大小排序展示,并以图表形式直观告诉开发资源情况

1、res 目录:资源优化

图片资源远程下发?

利用 Fresco 预下载图片接口下载到本地

1.1 图片资源优化

  1. 只保留一套 UI 设计的图
  2. png 压缩,之前用 py 脚本,发版本前会扫描 app 内所有大于 5k 的图片给压缩下
    1. py 脚本工具
    2. TinyPngPlugin Gradle插件
  3. webp 替换 png
  4. 用小的.9 图拉伸,替换大图

1.2 无用资源的移除

通过 gradle 的 resConfig "zh","ar",只保留必须的语言资源

1.3 资源的混淆

apk 资源混淆:AndResGuard

AndResGuard 微信开源的资源混淆插件,将冗余的资源名称换成简短的名字如 abc,资源压缩的效果要比代码瘦身的效果要好的多。

aab 资源混淆:aabResGuard

Layout 二进制文件裁剪优化

si 裁剪有有个 700K 优化空间

Json 文件压缩

原理就是在编译过程中找到 merged_assets 这个文件夹:去掉空格和换行

qnjvq

2、classesX.dex 优化:代码优化

一个功能尽量用一个库

比如加载图片库,不要 glide 和 fresco 混用,因为功能是类似的,只是使用的方法不一样,用了多个库来做类似的事情,代码肯定就变多了。

Proguard

proguard 不保留行号

# Proguard中keep住源文件及行号
-keepattributes SourceFile,LineNumberTable

google 相关库 proguard-rules 优化

通过对工程中现有 keep 规则进行优化,以达到包体积优化的效果。

目前分析混淆规则中 -keep class com.google.** { *; },keep 的范围过大

在 Shein App,删除以上规则后包体积收益 1MB+

SDK 混淆规则 备注
com.android.installreferrer:installreferrer -keep public class com. Android. Installreferrer.** { *; } Google Play Store 获取安装 App 引荐来源相关信息 SDK
com.google.firebase:firebase-perf -keep class com.google.firebase.** { *; } Firebase 性能监控
com.google.firebase:firebase-crashlytics -keep class com.google.firebase.** { *; } 崩溃
com.google.firebase:firebase-messaging -keep class com.google.firebase.** { *; } FCM
com.google.android.flexbox:flexbox The FlexboxLayoutManager may be set from a layout xml, in that situation the RecyclerView
Tries to instantiate the layout manager using reflection.
This is to prevent the layout manager from being obfuscated.
-keepnames public class com. Google. Android. Flexbox. FlexboxLayoutManager
FlexBox 布局组件
com.google.android.play:core \ Google Play Store App 发布,App 更新,App 下载安装、动态下发
com.google.zxing -keep class com.google.zxing.** {*;} <br> -dontwarn com.google.zxing.** 二维码
com.google.code.gson:gson -keepclassmembers class com.google.gson.**
-keepclassmembers class com. Google. Gson.** { public private protected *; }
-keep @interface com. Google. Gson. Annotations. SerializedName
-keepclassmembers class * { <br>    @com. Google. Gson. Annotations. SerializedName <fields>; <br>}
Gson
com.google.guava:guava UsingProGuardWithGuava · google/guava Wiki (github.com)
com.google.auto: auto-common \ Google 编译时代码生成辅助工具库

R 文件内联

通过把 R 文件里面的资源内联到代码中,从而减少 R 文件的大小

R 文件产生

在 Android 编译打包的过程中,位于 res/目录下的文件,就会通过 aapt 工具,对里面的资源进行编译压缩,从而生成相应的资源 id,且生成 R.java 文件,用于保存当前的资源信息,同时生成 resource.arsc 文件,建立 id 与其对应资源的值

R 文件结构

tr0zw
R.java 内部包含了很多内部类:如 layout、mipmap、drawable、string、id 等等,这些内部类里面只有 2 种数据类型的字段:

只有 styleable 最为特殊,只有它里面有 public static final int[] 类型的字段定义,其它都只有 int 类型的字段。

R 资源 id 表示

资源 id 用一个 16 进制的 int 数值表示。比如 0x7f010000,我们来解释一下具体含义

  1. 第一个字节 7f:代表着这个资源属于本应用 apk 的资源,相应的以 01 代表开头的话(比如 0x01010000)就代表这是一个与应用无关的系统资源。0x7f010000,表明 abc_fade_in 属于我们应用的一个资源
  2. 第二个字节 01: 是指资源的类型,比如 01 就代表着这个资源属于 anim 类型
  3. 第三,四个字节 0000: 指资源的编号,在所属资源类型中,一般从 0000 开始递增

R 文件冗余

Android 从 ADT 14 开始为了解决多个 library 中 R 文件中 id 冲突,所以将 Library 中的 R 的改成 static 的非常量属性。不能用 switch-case。
在 apk 打包的过程中,module 中的 R 文件采用对依赖库的 R 进行累计叠加的方式生成。如果我们的 app 架构如下:

fz5no
编译打包时每个模块生成的 R 文件如下:

在最终打成 apk 时,除了 R_app(因为 app 中的 R 是常量,在 javac 阶段 R 引用就会被替换成常量,所以打 release 混淆时,app 中的 R 文件会被 shrink 掉),其余 module 的 R 文件全部都会打进 apk 包中。这就是 apk 中 R 文件冗余的由来。而且如果项目依赖层次越多,上层的业务组件越多,将会导致 apk 中的 R 文件将急剧的膨胀。

javac 本身会对 static final 的基本类型做内联,也就是把代码引用的地方全部替换成常量;可以少了一次内存寻址,还可以删除内联后的 R 文件

module R 不是常量的原因
避免了不同 module 之间资源名相同时导致的资源冲突

在 Android 中,我们每个资源 id 都是唯一的,因此我们在打包的时候需要保证不会出现重复 id 的资源。如果我们在 library module 就已经指定了资源 id,那我们就和容易和其他 library module 出现资源 id 的冲突。因此 AGP 提供了一种方案,在 library module 编译时,使用资源 id 的地方仍然采用访问域的方式,并记录使用的资源在 R.txt 中。在 application module 编译时,收集所有 library module 的 R.txt,加上 application module R 文件输入给 aapt,aapt 在获得全局的输入后,按序给每个资源生成唯一不重复的资源 id,从而避免这种冲突。但此时,library module 已经编译完成,因此只能生成 R.java 文件,来满足 library module 的运行时资源获取。

不同版本 AGP 生成 R 文件的表现

keep R 文件混淆规则

-keepattributes InnerClasses

-keep class **.R
-keep class **.R$* {
    <fields>;
}
AGP3.5.2 R 文件

uebrq
AGP3.5 先生成 R.java,然后再编译成 R.class
反编译后,也是能看到 R 文件的
v69pp

AGP3.6 R 文件

AGP3.6 需要 Gradle5.6.4+
91si4
AGP 3.5.0 到 3.6.0 通过减少 R 生成的中间过程,来提升 R 的生成效率(先生成 R.java 再通过 Javac 生成 R.class 变为直接生成 R.jar)
jkv5h

AGP4.1 R 文件

AGP4.1,需要 Gradle6.5+
egh2a
AGP4.1.0 后,app 和 library 的 R 都会替换成了常量

小结
  1. AGP3.5.2/3.6.0/4.1.0 app module 中 R 都是常量,app module 都会内联替换成常量
  2. AGP3.5.2/AGP3.6.0,App 的 R 替换成了常量,library 还是 R.xxx.xxx 变量,不会替换
  3. AGP3.5.2→3.6.0,相比 3.5.2 不会生成 R.java,直接生成 R.jar
  4. AGP3.5.2 和 3.6.0,library 的 R
  5. AGP4.1.0 是做了对 R 文件的内联,并且做的很彻底,不仅删除了冗余的 R 文件,并且还把所有对 R 文件的引用都改成了常量
AGP3.5.2 AGP3.6.0 AGP4.1.0
是否生成 R.java
生成 R.java 路径 u44el
生成 R.java
不生成 不生成
app R.class/R.jar? w3xu5
app module 生成的 R.class 是常量
ja5u8app module 生成的 R.jar 是常量 k5qjpapp module 生成的 R.jar 是常量
library R.class/R.jar eclyq
library module 生成的 R.class 不是常量
8iict
library module 生成的 R.java 的不是常量
uz74z
library R.jar 不是常量
生成的 R class 的路径 50f59
/build/intermediates/javac/debug/classes/me/hacket/qiubaitools/R.class
xpkg7
build/compile_and_runtime_not_namespaced_r_class_jar/debug/R.jar
6gaym
build/compile_and_runtime_not_namespaced_r_class_jar/debug/R.jar
app R 内联 app module 的 R 都内联成了常量
library module 内联(查看 LoginActivity 字节码) fbyy6
p8ljr
at5vg
是,library module 的 R 被替换成了常量,但 R 文件没有删除
pm8j1

booster r inline (R.txt)

R.txt 存在哪?

**总结:**滴滴 booster 通过解析 R.txt 文件效率上要高于扫描 class,使用方便,逻辑清晰,library 的 R 资源的 Fields 会全部删除,并将引用 Library 包名的 int 数组会全部修改为应用包名,应用包名的 R 的 styleable 资源会全部保留。需要注意的是 Library 的 R class 没有被删除,所以应用中即使使用了反射获取资源 id 时也不会造成应用崩溃,使用反射肯定捕获了异常,但可能会造成页面异常,另外不支持根据资源名配置白名单,只能根据包名进行配置。

Bytex-shrink

字节跳动的 ByteX 会扫描所有 R 文件 class 并将相关信息存储到集合中,支持根据包名和资源名配置白名单,对 Styleable class 的 int 数组也做了 inline 处理,并且将无用 R 文件 class 进行了删除,最后还提供了 html 的报告,里面包含可能使用反射获取 R 资源的类信息,这可以帮助我们更好地配置白名单,由于 R 文件 class 也会被删除,所以如果应用使用反射获取资源可能会直接崩溃。相对于滴滴 booster,字节跳动的 ByteX 将无用 R 文件 class 也进行了删除和 R 资源中的 int 数组也进行了 inline 内联处理,处理更彻底。
和 AGP4.1 相比,做了什么?

  1. AGP4.1 只是内联了 R 并删除了 R 中的条目,但并没有删除 R.class;androidx 库中的 R.class 中的条目没有删除

o5xd6

  1. Bytex shrink-r 删除了 module 中的 R.class 和 androidx 库中的 R.class

1btw7

不需要内联的场景

  1. 反射用到 R 资源的地方
public static int getId(String num){
    try {
        String name = "weather_detail_icon_" + num;
        Field field = R.drawable.class.getField(name);
        return field.getInt(null);
    } catch (Exception e) {
        e.printStackTrace();
    }
    return 0;
}
  1. getIdentifier

数据情况

Ref

禁用 R 文件依赖传递 (最低 AGP4.2.0)

R 文件依赖传递,最上层的 module 拥有其依赖 module 的 R,不仅拖慢了编译速度,还增加了包的体积

android.nonTransitiveRClass=true

比如 Mashi 有个 lib_string module 专门放 string 资源的,每次都要传递的 app R,存在很大的空间浪费

ByteX 插件代码优化

  1. 编译器内联常量
  2. 编译期间优化掉Log调用

裁剪三方库

如 support 库,只保留有用的部分

3、lib 优化:so 优化

只编译指定平台的 so

一般我们都是给 arm 平台的机器开发,如果没有特殊情况,我们一般只需要考虑 arm 平台的。具体的方法是 app 下的 build.gradle 添加如下代码:只保留 armeabi(前几年) 或者 armeabi-v7a(目前)

android {
    defaultConfig {
        ndk {
            abiFilter "armeabi"
        }
    }
}

各个平台的差别如下:

平台 说明
armeabi-v7a arm 第 7 代及以上的处理器,2011 年后的设备基本都是
arm64-v8a arm 第 8 代 64 位处理器设备
armeabi arm 第 5、6 代处理器,早期的机器都是这个平台
x86 x86 32 位平台,平板和模拟器用的多
x86_64 x86 64 位平台

移除调试符号

  1. 自己编译的 so

release 包的 so 中移除调试符号。可以使用 Android NDK 中提供的 arm-eabi-strip 工具从原生库中移除不必要的调试符号
如果是 cmake 来编译的话,可以再编辑脚本添加如下代码:

set(CMAKE_C_FLAGS_RELEASE "${CMAKE_C_FLAGS_RELEASE} -s")
set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} -s")
  1. 别人编译的 so

联系 so 作者修改,一般很难联系到。

  1. 都下沉到 armeabi,代码判断使用指定的 so

对于项目当中使用到的视频模块的 So,它对性能要求非常高,所以我们采用了另外一种方式,我们将所有这个模块下的 So 都放到了 armeabi 这个目录下,然后在代码中做判断,如果是别的 CPU 架构,那我们就加载对应 CPU 架构的 So 文件即可。这样即减少了包体积,同时又达到了性能最佳。最后,通过实践可以看出 So 瘦身的效果一般是最好的。

so 压缩

so 动态下发

Android App Bundle(AAB)

Google Play 要求 2022 年 8 月份起新应用须打包为 AAB 格式,开发者上传打包文件整合成 aab 格式,根据不同的处理器/分辨率等下载对应的安装包,减少冗余,所以安装包会减小。
Android App Bundle 是一种发布格式,其中包含你应用所有经过编译的代码和资源,它将 APK 生成及签名交由 Google Play 来完成。
Google Play 就是基于对 aab 文件处理,将 App Bundle 在多个维度进行拆分,在资源维度,ABI 维度和 Language 维度进行了拆分,你只要按需组装你的 Apk 然后安装即可。如果你的手机是一个 x86,xhdpi 的手机,你在 google play 应用市场下载 apk 时,gogle play 会获取手机的信息,然后根据 App Bundle 会帮你拼装好一个 apk,这个 apk 的资源只有 xhdpi 的,而且 so 库只有 x86,其他无关的都会剔除。从而减少了 apk 的大小。

Dynamic Feature

Qigsaw 是一套基于 Android App Bundles 实现的 Android 动态组件化方案,它无需应用重新安装即可动态分发插件。

其他方案

插件化

原生功能转 H5 来做

Redex

数据

un43n

APK 瘦身量化指标

Apk 瘦身如何实现长效治理(防止劣化)?

1、发版之前与上个版本包体积对比,超过阈值则必须优化

在大型项目中,最好的方式就是 结合 CI,每个开发同学 在往主干合入代码的时候需要经过一次预编译,这个预编译出来的包对比主干打出来的包大小,如果超过阈值则不允许合入,需要提交代码的同学自己去优化去提交的代码

2、推进插件化架构改进

针对项目的 架构,我们可以做 插件化的改造,将每一个功能模块都改造成插件,以插件的形式来支持动态下发,这样应用的包体积就可以从根本上变小了。

配合 CI,监控上个版本的包

如何统计 apk 每个库的大小?

CI 监控每个版本包体积的变化

增量自动分析

通过将包分析能力的工具集成到打包脚本,在每次包构建成功时,也会同步产出基础的包内容信息,再通过进一步的分析后获得包中每个文件/模块的大小情况。当代码改动触发重新打出新包后,文件/模块通过一一对比的方式,找出哪些有新增,哪些被删除,哪些内容发生变动,以及变动产生的大小,并产出对比报告邮件。通过这样的方式让开发对代码增量有一个直观感受。
如何让包增量分析工具能在日常开发中持续稳定发挥作用呢?需要增量卡口设计

增量卡口设计

在之前,包大小差异通常都在拉出集成分支,打出版本 release 包时才发现,经常会震惊于这个版本的包又比上一个版本要大几 M,然后再紧急去寻找是什么需求集成导致的巨大增量。但这时发现包大小的问题已经非常滞后了,版本马上就要发布,这个时候即使抓到了剧增的源头,也很难在短时间内进行优化。
因此需要增加需求集成卡口,测试通过后在合入主分支之前,经过包增量确认再集成,而不是在集成后打出 release 包时。现在的做法如下,开发只需要提交代码,即可自动获得包增量分析报告。
其中包增量对比邮件内容,会包含与主分支最新构建、当前分支前一次构建,当前分支最初一次构建包的包大小和增量的对比结果。此外为了数据的准确性,需要开发在拉出开发分支后先构建一个基准包,并在提测和集成前合并一把主干,这样报告数据才会更准确。
最后是提测部分,开发同学发送提测邮件时需要标注本次提测包增量及图片压缩情况,若需求增量大于 100K,根据超出范围情况,需要备注原因和老板确认。bug 修复期间不免也会有代码改动,在测试完成后集成前,会再确认一次包增量情况再集成。

提交时做图片大小检查

提交的图片大于 30K,给个企微提醒

Ref